Repository navigation
Release: merge development into beta - #4580
github-actions[bot] wants to merge 311 commits into
Conversation
… goes through submit()
…m-destination-validator
… prettier-formatted
…ver rate bands A quantity accrues over ordered rate bands, optionally capped (ADR-118 C16): the shape of a stepped daily penalty, tiered pricing or a bracket table, so it is configuration for each of them. The validator now treats a lit operand as data, so a literal rate schedule is no longer refused as malformed.
metadata.completeVia = {app, action} makes the portal seam refuse a direct
completion with 409 complete-via-app, naming the action, after the party
check and before anything is stored. Only the named app completes the task
in-process (PortalTaskService::complete(viaApp: ...)). A portal reads the
same key off the task row and opens the app's action instead of the upload
form, so an app's own checks (a frozen checklist, a signature, a payment)
cannot be skipped by completing the task through the seam.
Change a-portal-task-names-the-app-that-completes-it.
…, failing closed A property can cite rows of another schema by a key property (referenceSchema + referenceKey, e.g. a code) and refuse their delete with onDelete RESTRICT. An unreadable citation blocks the delete as unverifiable; a citing table that does not exist yet counts as no citation. The property validator accepts the two keys and refuses referenceKey with any other action. uuid references are unchanged. Generic reference guard, capability C25 of hydra's procedures-are-configuration (decision 182).
…them by key Decision 186: a reusable decision table lives in OpenRegister as a generic capability (ADR-118 C15). A core decision-tables register, refused at save when the evaluator cannot run a table or its key is taken; DecisionTableStore resolves a table by key or uuid and evaluates it for apps; the decision-table flow step takes tableRef as an alternative to an inline table.
…browser e2e, @SPEC on three responder methods)
…ly as a mock
A fresh instance refused the zuiddrecht set: brp and kvk are mock-only
descriptors, and the real-configuration re-import correctly refuses a mock.
A requires entry now names the descriptor it wants: a slug for the real
one, {"register": "<slug>", "mock": true} for the mock, imported through
reimportMock() under openregister.mock and recorded for remove as before.
A plain slug never falls back to the mock. zuiddrecht declares brp and kvk
as mock requirements.
…erty (#4656) ObjectTreeService walks descendants, ancestors or both along the parent property a schema already declares (x-openregister-hierarchy.parent, else x-openregister-party.parentProperty), one query per level, with levels, cycle protection, the declared depth, the requested depth and the node cap, and the caller's read access on every node. Exposed as GET /api/objects/{register}/{schema}/{id}/tree. Decision 250.
… and nothing else (#4657) BrpPersonProvider::countResidentsAtAddressObject() asks Haal Centraal ZoekMetAdresseerbaarObjectIdentificatie with one field and answers { total, meta }. It asks only when the brp-haalcentraal source carries configuration.residentCount: true, and the call reaches the connector with withholdBodies so the residents' numbers are never stored. The router gains sourceConfiguration() and the withholdBodies call option. This is the openregister half of portaliq identity-registered-details T07. Assisted-by: ClaudeCode:claude-opus-5-5
…ock-register fix(example-sets): a set may require a register OpenRegister ships only as a mock
…roperty, and a config field may be a dot path The rehearsal R2 install logged 16,038 'meta is not a valid attribute' errors: shillinq's _meta property has a _meta column, and every magic-table reader took each _ column for object metadata, so MagicStatisticsHandler failed the whole row and the other readers dropped the property. MagicMetadataColumn now decides: a property the schema declares is a property unless ObjectEntity has that attribute. An unknown metadata column is skipped and noted once at debug. opencatalogi's publiccode schema was rejected on every import because the save-time check compared description.nl.longDescription with the top-level keys; the reader walks dot paths, so the check now accepts a path whose first segment is a property. example-set --check counted changes into the per-schema object count (12 dossiq cases against 10 declared); changes are now counted and printed apart.
… bulk writer asks it too check:strict's phpmd flagged static access, a long variable name and the bulk writer's method length; the write-side rule moves into MagicMetadataColumn::skippedOnWrite().
fix(magic-tables,example-sets): underscore properties read as properties, dot-path config fields, check counts like install
Bumps [zod](https://github.com/colinhacks/zod) from 4.6.5 to 4.6.7. - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](colinhacks/zod@v4.6.5...v4.6.7) --- updated-dependencies: - dependency-name: zod dependency-version: 4.6.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.63.0 to 1.64.0. - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](microsoft/playwright@v1.63.0...v1.64.0) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-version: 1.64.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [smalot/pdfparser](https://github.com/smalot/pdfparser) from 2.12.5 to 2.13.0. - [Release notes](https://github.com/smalot/pdfparser/releases) - [Commits](smalot/pdfparser@v2.12.5...v2.13.0) --- updated-dependencies: - dependency-name: smalot/pdfparser dependency-version: 2.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…#4625) Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.68.0 to 8.71.2. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/parser) --- updated-dependencies: - dependency-name: "@typescript-eslint/parser" dependency-version: 8.71.2 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [vue-material-design-icons](https://github.com/robcresswell/vue-material-design-icons) from 5.3.1 to 5.4.0. - [Release notes](https://github.com/robcresswell/vue-material-design-icons/releases) - [Commits](robcresswell/vue-material-design-icons@5.3.1...5.4.0) --- updated-dependencies: - dependency-name: vue-material-design-icons dependency-version: 5.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [webonyx/graphql-php](https://github.com/webonyx/graphql-php) from 15.37.3 to 15.38.1. - [Release notes](https://github.com/webonyx/graphql-php/releases) - [Changelog](https://github.com/webonyx/graphql-php/blob/master/CHANGELOG.md) - [Commits](webonyx/graphql-php@v15.37.3...v15.38.1) --- updated-dependencies: - dependency-name: webonyx/graphql-php dependency-version: 15.38.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
| if ($mapping === null) { | ||
| foreach ($payload as $name => $value) { | ||
| $name = (string)$name; | ||
| if (str_starts_with($name, '_') === false && str_starts_with($name, '@') === false) { |
There was a problem hiding this comment.
🔴 Blocker — A form payload's id picks the uuid, and a scoped draft save then writes over any draft with RBAC off
pairsOf() drops only _ and @ keys when a write has no mapping, so a submitter's id reaches $plan['object']. writeAll() passes uuid: null for a fresh submit or a new draft, and SaveObject::extractUuidAndSelfData() (SaveObject.php:3538-3539) then takes $data['id'] as the uuid. SaveObject looks that uuid up with _rbac: false and, when it exists, takes the update branch (handleObjectUpdate, SaveObject.php:3330).
For a scope-value owner (decision 195), writeAll() saves with _rbac: false. refuseSomebodyElsesDraft() returns early when RBAC is off, and DraftStatusPolicy::resolveStatus() accepts draft → draft. So a portal resident whose form has no mapping can send { "id": "<another person's draft uuid>", ... } to saveDraft() and overwrite that draft in the destination schema. The victim can be another resident or a Nextcloud user. The draft keeps its owner, so the victim later finds and submits content they did not type. The comment at lines 685-697 ("Updating or sending it was authorised by DraftFinder::own() before this write") does not hold on this path: own() is only asked when $draftId is set.
The same key affects a fresh submit under RBAC. A uuid the caller cannot read goes down checkSavePermissions()'s create branch (ObjectService.php:2275-2287). It is judged by the schema's create rule (for an anonymous visitor on a public form: the public create rule) and then stored over the hidden object. refuseSomebodyElsesDraft() closes that only for drafts.
I could not check whether the in-process callers (the portal) ever pass mapping: null. The spec explicitly allows it ("without a mapping the payload minus _ and @ keys is the object"), and FormsController::payload() forwards id too. Uncertain, so a blocker under Strict.
Impact: a form submitter (anonymous on a public form, or a portal resident through a scoped draft) can change existing objects of the destination schema that they cannot read, including other people's drafts.
Suggested fix: in pairsOf(), also drop id and uuid when no mapping is given, so a submit never chooses its uuid. The only way to name an existing object is then $draftId, which goes through DraftFinder::own(). As a backstop, in writeAll() refuse when $uuid === null and the object still carries an id.
Done when:
- a new
FormSubmitServiceTestcase:saveDraft()with a scoped owner,mapping: nulland payloadid= another owner's draft uuid either creates a new object with a fresh uuid or is refused, and the other draft's data is unchanged. The test goes red with theidfilter removed - the same for
submit()with an anonymous subject and theidof an existing object
Reachability: a scope-value owner is only created through DraftOwner::portalSubject() / scoped() by an in-process caller; no caller in this repository creates one, so the write path is reached through the portal app's use of saveDraft(). Reported as a blocker under Strict because the payload id is not stripped on a write that runs with RBAC off.
There was a problem hiding this comment.
Tracked as #4664: filed rather than fixed here because the fix lands through development and this release PR is re-cut after every merge.
| // A draft is removed for good, with its files: a tombstone of | ||
| // unsent input serves nobody, and on an archival schema the | ||
| // purge would refuse it for ever (decisions 180 and 216). | ||
| if ($objectToDelete->isDraft() === true) { |
There was a problem hiding this comment.
🔴 Blocker — Bulk delete ignores every draft rule
The draft delete rules live only in deleteObject(): the lookup under the read RBAC (which hides somebody else's draft), isDeletableDraft() for archival schemas, and permanent = true here. deleteObjects(), behind POST /api/bulk/{register}/{schema}/delete (BulkController::delete, @NoAdminRequired), has none of them.
| Contract branch (spec draft-owner-activation-and-lifetime) | Single delete | Bulk delete |
|---|---|---|
| Only somebody who can read a draft may change it | ✓ RBAC lookup hides it → 404 | ✗ filterUuidsForPermissions() → hasPermission(delete, objectOwner), which has no draft rule |
| A draft's delete is permanent, with its files | ✓ line 3190 | ✗ deleteHandler->deleteObject() without permanent, so a soft-deleted tombstone keeps the unsent input |
One draft.removed audit entry without content |
✓ written in the permanent branch of DeleteObject::delete() |
✗ not permanent, so no entry |
| A draft is deletable on an archival schema | ✓ isDeletableDraft() |
✗ rejectIfArchivalImmutable() refuses it, so the owner cannot bulk-discard their own draft there |
So any user with delete rights in a schema can remove another user's draft by uuid, and the removed draft's content stays in the trash.
Sibling of the open thread at DraftStatusPolicy.php:295 (bulk save over somebody else's draft). That thread covers the bulk save path, not the bulk delete path. The root cause is the same: PermissionHandler::hasPermission() has no draft rule. Putting keptFrom() into hasPermission() when an object is passed would close both threads and f3.
Suggested fix: in deleteObjects(), for each pre-resolved row that isDraft(): skip it (reason OBJECT_NOT_PERMITTED, the same answer as an unreadable row) when $_rbac is on and DraftStatusPolicy::keptFrom() is true; otherwise pass permanent: true and skip the archival gate, as deleteObject() does.
Done when:
- a
deleteObjects()test: user B bulk-deleting user A's draft gets it inskipped_uuidsand the draft still exists; A bulk-deleting it removes it permanently and writes onedraft.removedentry - the same test goes red with the new branch removed
There was a problem hiding this comment.
Tracked as #4665: filed rather than fixed here because the fix lands through development and this release PR is re-cut after every merge.
| @@ -91,7 +91,7 @@ class ReadableAuditTrailLister { | |||
| * | |||
| * @var string[] | |||
| */ | |||
| private const WITHHELD_FIELDS = ['session', 'request', 'ipAddress']; | |||
| public const WITHHELD_FIELDS = ['session', 'request', 'ipAddress']; | |||
There was a problem hiding this comment.
🔴 Blocker — /api/audit-trails/readable shows other users' draft contents
A draft's saves write ordinary create/update audit rows, and their changed column holds every property's old and new value (AuditTrailMapper.php:878-884). GET /api/audit-trails/readable (@NoAdminRequired) decides per row through mayRead() → PermissionHandler::hasPermission(action: 'read', objectOwner, object). hasPermission() has no draft rule (the draft rule lives only in the SQL predicate, MagicRbacHandler::reachableRowSqlFor()). Any user with read rights on the schema therefore reads, in this list, what another user typed into a draft that the object API hides from them ("a draft answers to its owner only", decision 180). WITHHELD_FIELDS removes session, request and ipAddress but keeps changed.
Sibling of the open thread at DraftStatusPolicy.php:295, which has the same root cause (hasPermission() has no draft rule) but is about a different path.
Impact: draft content (possibly personal data a resident has not yet decided to send) is readable by colleagues while the draft lives.
Suggested fix: add the draft rule to PermissionHandler::hasPermission() when an object is passed: refuse read (and every other action) when (new DraftStatusPolicy())->keptFrom($object, $schema, $userId) is true. That one change also closes f2 and the bulk-save thread.
Done when:
- a
ReadableAuditTrailListerTestcase: user B, with read rights on the schema, does not get the audit rows of user A's draft. It goes red without the draft rule - user A still gets them
There was a problem hiding this comment.
Tracked as #4666: filed rather than fixed here because the fix lands through development and this release PR is re-cut after every merge.
|
|
||
| $receivedAt = DateTimeImmutable::createFromMutable($this->time->getDateTime()); | ||
| // Creation side effects run now, once (decision 227): the created event, then the receipt. | ||
| (new ObjectEventGate(events: $this->events))->leftDraft(object: $created[0]['object'], receivedAt: $receivedAt); |
There was a problem hiding this comment.
🟡 Concern — Two concurrent leave-draft requests both announce the creation
The spec (object-events) says a listener on ObjectCreatedEvent "SHALL receive every object exactly once". promoteDraft() and DraftActivation::activate() (line 142) first check that the object is still a draft (DraftFinder::own() / ownDraft(), a plain read), then save, then call leftDraft() without asking whether the save left draft. Two requests on one draft, such as a double click or a client retry (with no Idempotency-Key or with different keys), can both pass the check before either commits. The second save then loads the now-active row, and DraftStatusPolicy::resolveStatus() returns early at line 114 because active === active, so it raises no refusal. The save goes through as an ordinary update: ObjectEventGate::updated() sends ObjectUpdatedEvent, and leftDraft() sends ObjectCreatedEvent and ObjectActivatedEvent a second time. The result is duplicated webhooks, flows, created notification rules and receipts. Nothing locks the draft, and the save has no version check.
Suggested fix: let the save report whether it used the promotion allowance, for example DraftStatusPolicy::consumePromotion(uuid): bool, or check isset(self::$promotions[$uuid]) after the write. Call leftDraft() only when the allowance was used, and otherwise answer as for a draft that is gone (404/409). Better still, refuse the write in resolveStatus() when an allowance exists for a uuid that is no longer a draft.
Done when:
- a
FormSubmitServiceTestand aDraftActivationTestcase wheresaveObjectreturns an object whose stored status was alreadyactive(the allowance was not used): noObjectCreatedEventis dispatched. Both go red with the check removed
| */ | ||
| private function expiredIn(Register $register, Schema $schema, DateTimeImmutable $cutoff): array { | ||
| $expired = []; | ||
| for ($page = 0; $page < self::MAX_PAGES; $page++) { |
There was a problem hiding this comment.
🟡 Concern — Drafts beyond the 5,000-read cap are neither expired nor counted as deferred
The spec says: "at most 5,000 drafts read per register and schema. What is left SHALL be counted as deferred and SHALL wait for the next run." expiredIn() reads every draft (_drafts=true), with no date filter, in PAGE_SIZE pages up to MAX_PAGES, and filters by age in PHP. When a schema holds more than 5,000 drafts, the expired ones after the 5,000th row are never read. They are not counted as expired or deferred, so the log and occ openregister:drafts:expired report less than is really there. Whether a later run ever reaches them depends on the list's default sort. With a stable order, the same first 5,000 rows are read on every run, so expired drafts further down are never removed.
Suggested fix: filter in the query (@self[updated] before the cutoff, with a fallback to created), so the 5,000 rows read are 5,000 expired drafts. When the last page is full, count what remains as deferred (or report "more than N").
Done when:
- a
DraftExpiryTestcase with an objects fake that returns 5,000 fresh drafts followed by expired ones: the expired ones are removed, or counted as deferred, and never missing from the counts
| // A reference by key only refuses: the other actions rewrite or | ||
| // delete by uuid and would act on the wrong value. | ||
| $referenceKey = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null)); | ||
| if ($referenceKey !== null && strtoupper((string)$property['onDelete']) !== 'RESTRICT') { |
There was a problem hiding this comment.
🟢 Minor — Validator and service disagree on referenceKey: uuid
ReferentialIntegrityService::extractReferenceKey() treats referenceKey empty or uuid as a plain uuid reference (all onDelete actions apply). The validator refuses any non-null referenceKey unless onDelete is RESTRICT, so referenceKey: uuid with CASCADE is refused at save although the service would honour it. Use the same normalisation in both (e.g. a shared static ReferenceKey::of($property)).
| * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight | ||
| */ | ||
| private function applyBlockedExclusion(IQueryBuilder $qb, TaskInboxCriteria $criteria): void { | ||
| if ($criteria->includeBlocked === true || $criteria->objectUuid !== null || $criteria->runUuid !== null) { |
There was a problem hiding this comment.
🟡 A blocked portal task is still listed and completable in the portal
#4595 (a-task-may-wait-on-another-task) says an inbox read SHALL leave out every blocked task, in its results and its total. The exclusion lives in applyBlockedExclusion(), which only applyInboxPredicates() calls. An external (portal) task is excluded from every Nextcloud inbox (applyExternalExclusion()), so the portal list is the only inbox it has. That list is PortalTaskService::listForSubject() (extended by #4569) over TaskMapper::findOpenExternalForParty() / countOpenExternalForParty(), and applyPartyPredicates() filters on assignee, performer type and is_terminal only. Composed:
- A portal task created with
blockedByshows up in the citizen's task list and count the moment it is created, not when its blocker closes. Thereleasedre-announcement fromTaskBlockerReleaseListenerthen announces a task the party has been looking at all along. PortalTaskService::complete()→TaskService::complete()never readsblockedBy(the only blocker check inTaskServiceisrefuseHandWrittenState()on create), so the party can complete the task the case meant to hold back.- Each row goes through
TaskInboxService::row()withoutopenBlockers(PortalTaskService.php:137), so every row runs its own blocker query. design.md D-3 says "No query per row".inbox()batches; the portal list does not.
Neither PR saw the other: #4569 merged on 11 Oct, #4595 on 12 Oct.
| * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft | ||
| */ | ||
| public function created(ObjectEntity $object): void { | ||
| if ($object->isDraft() === true) { |
There was a problem hiding this comment.
🟡 Personal data in a draft cannot be found by a data-subject request
Three PRs combine here. None of them covers this alone:
- feat(objects): a draft is the destination object, in status draft (decision 180) #4566 makes a draft the destination object. It holds the half-filled form input, which is often a name, address, BSN or e-mail.
- feat(objects): a draft holds its creation side effects until it leaves draft (decision 227) #4633 holds every
ObjectCreatedEvent/ObjectUpdatedEventlistener from a draft until it leaves draft.ObjectChangeListeneris one of them (the spec names it). It queues text extraction and entity recognition, and those writeopenregister_entities/openregister_entity_relations. - feat(objects): a draft's owner makes it definitive, and a schema sets a draft lifetime (decision 216) #4600 lets a schema keep drafts forever (
draftLifetimeDays: false).
The data-subject machinery finds a person's objects only through that index: DataSubjectRequestService::findSubjectObjects() → discover() → matchEntities() reads openregister_entities joined to openregister_entity_relations. Erasure preview and export use the same path (ErasurePreviewService::preview() line 128). A draft is never in that index, so access and erasure requests miss it. On a schema with draftLifetimeDays: false that lasts indefinitely. Even when the entity index does hit a draft, loadObject() reads it with _rbac: true, and the draft predicate hides it from anyone but its owner and administrators.
Removing a draft does not fully remove its data either. Every save of a draft writes a full create/update audit row with its values (SaveObject lines 4018-4027 and 6352). The removal spec keeps those rows on purpose ("The entries the draft's own saves wrote before the removal are not changed"), but the delete requirement promises "no tombstone of unsent input is kept" (openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md:189). Both statements cannot be true.
The draft-creation-side-effects proposal lists text extraction as deliberately held, with no mention of GDPR. I did not check whether a DSAR export has a second, non-index discovery path. Grep found none (findSubjectObjects is the entry point used by erasure).
|
|
||
| $blockers = []; | ||
| foreach ($referencing as $refObj) { | ||
| if ($refObj->getDeleted() !== null && empty($refObj->getDeleted()) === false) { |
There was a problem hiding this comment.
🟡 Another user's hidden draft blocks a RESTRICT delete
#4570 adds RESTRICT by key: keyReferenceBlockers() → findReferencingByKey() → findReferencingInMagicTable(). The $ref RESTRICT path that already existed reaches the same raw SQL. That SQL filters _deleted IS NULL and nothing else, and the loop here skips only deleted rows. #4566 adds drafts: a draft is visible to its owner only and expires after 30 days. A draft holds whatever a half-filled form cited, such as a code from a code list.
Together: user A's draft that cites code X stops user B from deleting X. B gets a 409 whose blockers[].objectUuid names an object B cannot open, list or resolve. B can only wait until A sends the draft or it expires. A pending draft can also block a planned deletion. The draft spec calls a draft "not yet a record: it holds unsent input", and the delete path then counts it as a live referrer. CASCADE/SET_NULL on uuid references reach into other users' drafts in the same way.
Pick one rule for drafts in referential integrity and apply it to both lookups (uuid and key). Either drafts do not block (they cannot be seen and will expire), or they block without revealing their uuid to a caller who cannot read them.
| - **Word-family text extraction now captures all content and supports legacy `.doc` and `.odt`.** `TextExtractionService::extractWord()` was rewritten around a recursive PhpWord element walker (`walkWordElements`, depth-capped at `MAX_WORD_DEPTH = 50`) that descends tables (rows → cells, including nested tables and in-cell text-runs / list items), section headers and footers, and document-level footnotes/endnotes — content the previous 2-level traversal silently dropped (notably entire tables, because a PhpWord `Table` exposes `getRows()`, not `getElements()`, so the old loop skipped it). The PhpWord reader is now selected from the file MIME / extension (`resolveWordReader`): `.docx` → `Word2007`, legacy `.doc` → `MsDoc`, `.odt` → `ODText` — previously the default `Word2007` reader was always used, so `.doc` and `.odt` failed to load even when routed. `application/vnd.oasis.opendocument.text` is added to the Word routing in `isWordDocument()`. (`text-extraction-word-completeness`) | ||
|
|
||
| ### Behaviour changes | ||
| - **A draft no longer fires `ObjectCreatedEvent`, `ObjectUpdatedEvent` or `ObjectDeletedEvent` (`draft-creation-side-effects`, decision 227).** A draft is an object in status `draft`. Flows, webhooks, activity, push, metrics and notifications no longer run on a draft's saves or its delete. They run once, when the draft leaves draft: `ObjectCreatedEvent` is dispatched then, followed by `ObjectActivatedEvent`. A listener on `ObjectCreatedEvent` needs no status check. Do creation work on `ObjectCreatedEvent` only. A listener that does it on `ObjectActivatedEvent` too runs twice. Objects that never were a draft behave as before. |
There was a problem hiding this comment.
🟡 CHANGELOG omits this release's upgrade-visible behaviour changes
The release adds three CHANGELOG bullets, all about drafts. These changes ship to beta with no entry:
- fix(rbac): a roles block that resolves to nothing grants nothing #4589: a schema/register
rolesblock that resolves to nothing used to leave the schema open and now grants nothing on read/create/update/delete (PermissionHandler::grantNothingWhenEmpty()). On upgrade this can take access away from users. It needs an upgrade note like the drafts one. - feat(files): a frozen or archived object's files refuse every write, through the files API and through Files or WebDAV #4549: every file write, create, delete and rename under an archived or frozen object's folder is now refused, also through WebDAV/Files (
FrozenNodeWriteListeneron four node events). Clients that attach files to archived objects start getting errors. - fix(settings): personal settings move into the user settings dialog #4587: the personal settings section (
<personal>OpenRegisterPersonal) is removed and moved into the in-app user settings dialog. Nextcloud's personal settings no longer show it. - feat(audit): an object's history is readable by whoever may read the object #4614: an object's audit trail is now readable by anyone who may read the object. That is an exposure change admins should know about.
- New public API surface:
POST /api/forms/{formId}/submit|uploads|draft,POST /api/forms/validate(feat(forms): a form submits into its destination object (decision 179) #4563),POST /api/objects/{register}/{schema}/{id}/activate(feat(objects): a draft's owner makes it definitive, and a schema sets a draft lifetime (decision 216) #4600),GET .../{id}/tree(feat(relations): read an object's tree along its declared parent property #4656),/api/record-templatesCRUD (train: land 10 openregister PRs (12 Oct) #4595),POST /api/migration-packs/preview(feat(migration-packs): view and edit migration packs in OpenRegister #4602), and theexample-setocc commands.
git grep -n -i 'roles block\|frozen\|archived object\|personal settings' CHANGELOG.md returns nothing.
There was a problem hiding this comment.
Verdict: REQUEST_CHANGES (Strict)
Not ready for beta. Five blockers sit in code no second person reviewed, three of them in the draft model: the draft rule lives in the SQL read predicate only, and PermissionHandler::hasPermission() has none, so bulk delete, the readable audit trail and (on Wilco's open thread) bulk save all reach other users' drafts. Adding the draft rule to hasPermission() when an object is passed would close all three. Two more blockers landed after Wilco's review (#4656, #4661). Under Strict every 🟡 below blocks as well.
🔴 Blockers (5)
- A form payload's
idpicks the uuid, and a scoped draft save then writes over any draft with RBAC off —lib/Service/Form/FormSubmitService.php:592(introduced in #4563, #4566). With no mappingpairsOf()passes the payload'sidthrough,SaveObjecttakes it as the uuid, and a scoped draft save writes with_rbac: false, so any object in the destination schema can be overwritten. Tracked in #4664. - Bulk delete ignores every draft rule: others' drafts deleted, soft-deleted, no
draft.removed—lib/Service/ObjectService.php:3190(introduced in #4566, #4600).filterUuidsForPermissions()reads across all magic tables and checks only the schema'sdeletepermission, so a bulk delete removes other users' drafts that a single delete would refuse. Tracked in #4665. /api/audit-trails/readableshows other users' draft contents —lib/Service/Audit/ReadableAuditTrailLister.php:94(introduced in #4566, #4614). The lister resolves objects across all magic tables and askshasPermission('read'), which has no draft rule, so anyone who may read the schema sees thechangedvalues of another user's draft. Tracked in #4666.- Tree
fieldsreturns writeOnly, property-restricted and encrypted values raw —lib/Service/Relation/ObjectTreeService.php:479(introduced in #4656).GET …/tree?fields=a,bcopies each named key straight from the raw object onto every node, with no property RBAC, writeOnly redaction or decryption. Tracked in #4667. - Bulk writer lets a
_-named schema property overwrite metadata columns —lib/Db/MagicMapper/MagicBulkHandler.php:217(introduced in #4661). A schema property whose name is a metadata column (_owner,_organisation,_status, …) passesskippedOnWrite(), and line 233 writes the request's value into that column after line 178 stamped it. Tracked in #4668.
🟡 Concerns (35)
- Two concurrent leave-draft requests both announce the creation —
lib/Service/Form/FormSubmitService.php:340(introduced in #4633, #4600).DraftStatusPolicyreturns early when the asked status equals the current one before the promotion check, andleftDraft()runs unconditionally, so two concurrent leave-draft requests both release the creation events. - Drafts beyond the 5,000-read cap are neither expired nor counted deferred —
lib/Service/Object/DraftExpiry.php:284(introduced in #4600). The expiry query reads at most 5,000 drafts with no date filter and checks age in PHP afterwards, so drafts past the cap are neither expired nor counted as deferred. - A draft save never claims its upload tokens —
lib/Service/Form/FormSubmitService.php:229(introduced in #4563).saveDraft()maps upload tokens into$plan['files']and writes the files onto the draft, but unlikesubmitAll()(line 280) it never calls$this->uploads->claim(). - A required generated identifier blocks every submit and activation —
lib/Service/Object/DraftStatusPolicy.php:229(introduced in #4600, #4633).serverFilledExcusals()excuses a property fromrequiredwhen it has adefault,computed, orx-openregister.serverSet. - Advance-a-case step moves the oldest realisation of a repeating item —
lib/Service/Case/Nodes/CaseAdvanceNode.php:218(introduced in #4595).advance()transitions the first row whosekeymatches, and rows sort by position then id, so with a repeating item it moves the oldest (terminal) realisation. - Case step with a configured uuid anchors the plan to the wrong register/schema —
lib/Service/Case/Nodes/CaseNodeBase.php:185(introduced in #4595). The configureduuidtemplate replaces the target object, so the case can be about another object than the item the step received. - Items ensured as active never arm their due/expiry timers —
lib/Service/Case/CasePlanEnsurer.php:347(introduced in #4544, #4595).ensure()inserts missing items directly in their state, bypassing the state machine, so noCaseItemTransitionedEventfires and their due/expiry timers are never armed (sibling of the openCasePlanItemTimers.php:219thread). - System-created plans realise flows as a non-user
system:<app>—lib/Service/Case/CasePlanService.php:191(introduced in #4544).createPlanAsSystem()passessystem:<app>as the actor toinsertPlan(), which runscascade->evaluate(actor: 'system:<app>'). - bandRate skips schedule validation for a unit of zero or less —
lib/Service/Calculation/RateSchedule.php:137(introduced in #4571).rateAt()returns null for$unit <= 0beforenormalise()runs, so a malformed schedule (empty, not a list, falling bounds, an open band first) is not refused when the unit is 0 or negative. - An empty own property fills a deep-link placeholder with nothing —
lib/Service/Search/ObjectSearchResultFormatter.php:196(introduced in #4555). An empty own property is substituted as'', so no literal{name}survives and the deep-link fallback never triggers. - Duplicate decision-table key check fails open —
lib/Listener/DecisionTableValidationListener.php:205(introduced in #4595).keyTakenBy()answers "no clash" (null) when the search for an existing key throws, so the save goes through with a duplicatekey. - Admin-UI re-import silently replaces local authorization blocks —
lib/Controller/RegisterDescriptorController.php:152(introduced in #4618). #4618 routes the Register descriptors panel's button through the newreimport()(via: 'admin settings: Register descriptors'). - OpenRegister descriptor re-import uses a different configuration id than install —
lib/Service/RegisterDescriptorService.php:290(introduced in #4618).importDescriptorFile()callsimportFromApp(appId: $appId, ...)with$appId = 'openregister', forced, with the descriptor's own version. - Remove reads any lookup error as already deleted —
lib/Service/ExampleSet/ExampleSetStore.php:288(introduced in #4603, #4612).ExampleSetStorecatches everyThrowableon the lookup and reads it as "already gone", andExampleSetStoreTest.php:154locks that in (sibling of the openExampleSetInstance.php:159thread). - Reinstall duplicates an object when its read fails —
lib/Service/ExampleSet/ExampleSetInstaller.php:652(introduced in #4603).kept()getsnullfromstore->get()on anyThrowable, so a reinstall whose read failed creates a second object and overwrites the record id (sibling of the openExampleSetInstance.php:159thread). - Change and lookup take the first match, not exactly one —
lib/Service/ExampleSet/ExampleSetChanges.php:332(introduced in #4603). REQ-EXS-011 says a change has 'awherethat finds exactly one existing object by business key'; REQ-EXS-002 says a lookup fills 'the id of the one row a lookup by business key finds'. - Reinstall overwrites user edits to reference fields —
lib/Service/ExampleSet/ExampleSetInstaller.php:386(introduced in #4632).refreshReferences()resolves every top-level field whose declaration holds{{object:}}or{{lookup:}}and writes each one whose stored value differs. - Install, remove, install leaves a required register empty —
lib/Service/ExampleSet/ExampleSetPrerequisites.php:120(introduced in #4603, #4658). Remove soft-deletes the objects of the recorded import jobs (ExampleSetRegisters::removeImport()) but leaves the register and its schemas, which the same install created. - Remove deletes an organisation others started using —
lib/Service/ExampleSet/ExampleSetInstance.php:163(introduced in #4603).ExampleSetInstance::delete()deletes the organisation by uuid when the record lists it. - Generated config value deleted even after it was changed —
lib/Service/ExampleSet/ExampleSetAppConfig.php:146(introduced in #4603).revert()skips the 'changed since' check for a generated entry (($entry['generated'] ?? false) === false && $changed === true) and then always callsdeleteKey()for it, becausehadis ignored whengeneratedis true. - Local edits and shipped seed versions share one sequence —
lib/Service/Configuration/ImportHandler.php:3612(introduced in #4635). The shipped@self.versionis compared with the object's own version, which every local save bumps, so a local edit can mask a shipped update and a later shipped version overwrites the edit. - Pack editor shows untranslated validator and engine messages —
lib/Service/MigrationPack/PackDefinitionValidator.php:205(introduced in #4602). #4602 rewrote these messages for the editor ('Mapping row 4', 'the number the pack editor shows in its table'), andexplainError()showsdetails.errorsas a list under the save error (MigrationPackDetail.vue:446). - Tree endpoint skips the doelbinding (PurposeGuard) check —
lib/Controller/ObjectRelationsController.php:507(introduced in #4656).tree()authorises withObjectReadAccess::readable()only. - An access-check failure reads as a complete tree —
lib/Service/Relation/ObjectTreeService.php:437(introduced in #4656). The class promises "The answer says which bound stopped it, so a caller can refuse a partial tree instead of reading it as the whole one". - Node cap bounds the answer, not the work —
lib/Service/Relation/ObjectTreeService.php:302(introduced in #4656). The node cap is checked afterchildrenOf()has fetched every child row of the level with noLIMIT, so it bounds the answer but not the work. - Ancestor nodes name the uuid of an ancestor the caller may not read —
lib/Service/Relation/ObjectTreeService.php:390(introduced in #4656). For each ancestor node,parentis the grandparent uuid read from the ancestor's raw data (line 390), whether or not the caller may read the grandparent. - RESTRICT by key never blocks a scalar reference on MySQL/MariaDB —
lib/Service/Object/ReferentialIntegrityService.php:1164(introduced in #4570).findReferencingInMagicTable()quotes the column with"…", which MySQL/MariaDB read as a string literal, so the new key-RESTRICT path never finds a scalar reference there (the helper is already onbeta). - Resident count needs an integriq that beta does not ship —
lib/Service/Integration/ExternalIntegrationRouter.php:737(introduced in #4657).dispatch()passeswithholdBodies: trueas a named argument to integriq'sCallService::call. - An oversized deferred entry silently loses its listener side effect —
lib/Service/Deferral/ListenerDeferralService.php:309(introduced in #4616). An entry larger than the job argument budget iscontinued with one error log line: the notification / webhook / projection it carried never runs, and nothing retries it. - Object-folder endpoints have no frozen/archived guard —
lib/Controller/FilesController.php:315(introduced in #4549). REQ-OAS-007: every write to a frozen or archived object's files "SHALL be refused… the API SHALL answer 409". #4549 wiresFileWriteGuardintoFilesController::ensureObjectAccess()only. - Draft activation answers the raw entity, writeOnly values included —
lib/Controller/ObjectDraftController.php:182(introduced in #4600).activate()returns$activated->jsonSerialize(): the saved entity straight fromsaveObject(), never passed throughRenderObject. - A blocked portal task is still listed and completable in the portal —
lib/Db/TaskMapper.php:592(introduced in #4595, #4569).applyPartyPredicates()has noblocked_bypredicate (only the inbox path callsapplyBlockedExclusion()), so a blocked task is still listed and completable in the portal. - Personal data in a draft cannot be found by a data-subject request —
lib/Service/Object/ObjectEventGate.php:86(introduced in #4566, #4600, #4633). Draft side effects are held byObjectEventGate, including the entity-index listener, so personal data in a draft never reachesopenregister_entities, the only source a data-subject request searches. - Another user's hidden draft blocks a RESTRICT delete —
lib/Service/Object/ReferentialIntegrityService.php:1121(introduced in #4566, #4570). The RESTRICT lookup filters on_deletedonly, so another user's hidden draft blocks the delete andblockers[].objectUuidnames a draft the deleter cannot open. - CHANGELOG omits this release's upgrade-visible behaviour changes —
CHANGELOG.md:37(introduced in #4589, #4549, #4587, #4614, #4563, #4600, #4656, #4595, #4602). The release adds three CHANGELOG bullets, all about drafts, for 47 PRs; frozen files, the roles-block change and the moved personal settings are not mentioned.
🟢 Minor (16)
- Draft sweep counts are logged at info, hidden at the default log level —
lib/BackgroundJob/DraftExpiryJob.php:123(introduced in #4600). The sweep's per-schema and total counts are logged atinfo, below the default log level, so the promised "counted zero" never reaches the log. /api/forms/validatereads any schema across organisations —lib/Controller/FormsController.php:126(introduced in #4563). Any signed-in user can name any schema by id, uuid or slug.- The PATCH refusal of somebody else's draft is a 403, so a draft id is an oracle there —
lib/Service/ObjectService.php:2345(introduced in #4600). The form path takes care that "anything else answers the same 404, so a draft id is not an oracle" (FormSubmitService, DraftFinder, ObjectDraftController). - Decision tables resolve across every organisation —
lib/Service/Dmn/DecisionTableStore.php:158(introduced in #4595).lookup()(and the duplicate check inDecisionTableValidationListener::keyTakenBy()) reads thedecision-tablesregister with_rbac: false, _multitenancy: false. - Reader's object history keeps another object's uuid from a cascade —
lib/Service/Audit/HistoryEntryRedactor.php:159(introduced in #4614).filterChanged()withholds properties but notchanged.cascadeContext, whichAuditTrailMappersets to another object's uuid. - Hidden decision still shows through
anonymized/anonymizedValue—lib/Service/Decision/DecisionVisibility.php:59(introduced in #4595).MASKED_FIELDSblanksbases,skipAnonymizationanddecisionRecord, and setsdecisiontohidden. - OAS still reads an unresolved roles block as open —
lib/Service/Rbac/EffectiveAuthorization.php, no changed line within reach, so no thread (introduced in #4589). #4589 makesPermissionHandler::expandRoles()answer a roles block that resolves to nothing with four empty verbs (deny-all). - --check also creates OpenRegister organisations —
lib/Service/ExampleSet/ExampleSetPrerequisites.php:74(introduced in #4603).check()runsensureOrganisations(), so--checkcreates organisations, and REQ-EXS-004 and REQ-EXS-016 contradict each other on it. - Import jobs attributed to the set by a time window —
lib/Service/ExampleSet/ExampleSetRegisters.php:138(introduced in #4603).recordedImport()diffsjobIds()before and after the import, acrossopenregisterandopenregister.mock. - Install record kept in a non-lazy app-config value —
lib/Service/ExampleSet/ExampleSetRecord.php:193(introduced in #4603).write()stores the whole record (every object id, change, file, app-config entry and preference) withsetValueString()withoutlazy: true. - Pack import answers 201 when it replaced a pack —
lib/Controller/MigrationPacksController.php:293(introduced in #4602).import()knows whether it created or replaced a pack ($result->created, logged ascreated/replaced), but answers 201 either way with only the pack in the body. - New constants split DEFAULT_FIELDS from its docblock —
lib/Service/Integration/Providers/BrpPersonProvider.php:109(introduced in #4657). The three new constants sit betweenDEFAULT_FIELDSand its docblock, which now documents the wrong constant. - Listener never checks the object folder node itself —
lib/Listener/FrozenNodeWriteListener.php:223(introduced in #4549).candidateOwnerFolder()starts at$node->getParent(), so when the node IS the<uuid>object folder (delete or rename/move of the whole folder) no candidate is found and the operation is allowed. - retentionDays accepts 0, negatives and non-numbers silently —
lib/Service/Export/ExportProfileService.php:420(introduced in #4595).(int)$data['retentionDays']stores0,-5or"abc"silently, and the recorder then clamps exports to expire one minute after production. - blockedBy may name any task, readable or not —
lib/Service/Task/TaskBlockerGuard.php:106(introduced in #4595).assertDeclarable()resolves the blocker with the unscopedTaskMapper::findByUuid(); there is no check that the caller may see it. - Validator and service disagree on
referenceKey: uuid—lib/Service/Schemas/PropertyValidatorHandler.php:982(introduced in #4570).ReferentialIntegrityService::extractReferenceKey()treatsreferenceKeyempty oruuidas a plain uuid reference (allonDeleteactions apply).
✅ Verified clean
- BRP resident count (#4657): answers, logs and the call log carry a number only, no name, BSN or address id; there is no HTTP route in OpenRegister for it.
- Migration-pack endpoints are admin-gated; example sets are CLI-only; pack and set loading realpath-checks fragment files (no path traversal).
- The five migrations in this release are guarded, none returns
null, andinfo.xmlis bumped over beta; no doubleObjectCreatedEventon draft promotion.
Tracking issues
- #4664 — A form payload's
idpicks the uuid, and a scoped draft save then writes over any draft with RBAC off - #4665 — Bulk delete ignores every draft rule: others' drafts deleted, soft-deleted, no
draft.removed - #4666 —
/api/audit-trails/readableshows other users' draft contents - #4667 — Tree
fieldsreturns writeOnly, property-restricted and encrypted values raw - #4668 — Bulk writer lets a
_-named schema property overwrite metadata columns
Scope decision: full, provenance-driven review of the whole release, in five slices (forms and drafts; decisions, cases, flow, RBAC and audit; example sets, migration packs and import; database, relations, BRP and files; a cross-PR integration pass). 47 merged PRs, 0 direct commits: none had an approving review by someone other than its author, so all code was read at full depth. Wilco's 31 open threads were not repeated; siblings at other sites are marked as such.
Not read: about 560 files, mostly l10n/, tests/, openspec/ and src/ outside the migration-pack editor; translation completeness, lint, types and the unit suite are left to the green quality checks.
CI: every required check is green on 8e7bbd8; deploy / Deploy Documentation is red (not required) and E2E (Playwright) was skipped.
🤖 Generated with Claude Code
`?dunning.currentStage=2` (and `['dunning.currentStage' => 2]` from PHP) answered an empty page: the request layer rebuilds the mangled key into `['dunning' => ['currentStage' => 2]]`, which applyObjectFilters read as a list of UUIDs, and a dotted key was an unknown property that added `1 = 0`. NestedPathFilter takes the filters on a path inside an object-typed property out of the filter map, in both spellings and at any depth, and writes their SQL against the JSON column (`#>>` on PostgreSQL, JSON_EXTRACT on MariaDB and MySQL): equality, a list, in, notIn, ne, isnull, like, and gt/gte/lt/lte, numeric when the operand is a number. The QueryBuilder path and the raw UNION path both use it. A segment that is not a plain name matches nothing; an encrypted object property is refused as its column filter is. A filter on the object property itself keeps its meaning. Assisted-by: ClaudeCode:claude-opus-5-5
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.
Changelist
Snapshot of
developmentat 8e7bbd8 (16 Oct 2026) againstbetaat b996608: 46 PRs, 13 more bundled inside them. Thechore(sync)PR that carries beta back into development is left out. PRs merged to development after this snapshot are not listed yet.Mostly the case, draft and forms engine work (decisions 179 to 229), example sets and RBAC refinements, plus the migration-pack editor (#4602) that stackiq's CMDB import uses to show and edit its mapping packs.
Features (21)
Fixes (14)
Trains (bundled PRs) (2)
portal:<subjectRef>(decision 195)Dependencies and security (7)
@typescript-eslint/parserfrom 8.68.0 to 8.71.2@playwright/testfrom 1.63.0 to 1.64.0Maintenance (CI, gates, specs, docs) (2)